Skip to content

feat(backend): PostHog organization groups + $groupidentify#1925

Merged
riderx merged 2 commits into
mainfrom
feat/posthog-groups
Apr 21, 2026
Merged

feat(backend): PostHog organization groups + $groupidentify#1925
riderx merged 2 commits into
mainfrom
feat/posthog-groups

Conversation

@WcaleNieWolny
Copy link
Copy Markdown
Contributor

@WcaleNieWolny WcaleNieWolny commented Apr 21, 2026

Summary

Server-side events carry groups: { organization: orgId } to PostHog so dashboards can break down by org/plan. Adds a new groupIdentifyPosthog helper and calls it on org creation + Stripe lifecycle changes to keep org properties (plan, status, etc.) fresh.

Why

The LogSnag → PostHog KPI port we just did showed that backend events land on PostHog with distinct_id = record.id (the org/app UUID), creating anonymous person records with no way to correlate to users or aggregate by org. PostHog's Groups feature is the canonical B2B fix — events attributed to a group type can be broken down, cohorted, and flagged by group properties without changing the distinct_id scheme.

What changed

Plumbing

  • utils/posthog.ts: trackPosthogEvent now adds $groups to the capture body when a groups map is supplied. New exported groupIdentifyPosthog(c, { groupType, groupKey, properties }) posts $groupidentify events.
  • utils/tracking.ts: SendEventToTrackingPayload gains an optional groups?: PostHogGroups field; executeTracking forwards it to PostHog. LogSnag's .track() ignores the extra field (same pattern as the existing bento / sentToBento fields).

Org $groupidentify

  • on_organization_create — identifies the new org with { name, management_email, customer_id, created_by, created_at, website }.
  • stripe_event — on subscription created/updated, refreshes { plan_name, plan_status, plan_type, subscription_status_name }; on cancel, sets { plan_status: 'canceled', canceled_at }.

Both calls run inside backgroundTask so they don't block the webhook response.

groups propagated

Every existing sendEventToTracking call that has an orgId in scope now passes groups: { organization: orgId }:

  • Triggers: on_organization_create, on_app_create, on_version_create, on_deploy_history_create, credit_usage_alerts, all 6 stripe_event paths.
  • Private endpoints: upload_link, delete_failed_version, events (when trackingUserId is known).
  • utils/plans.ts: all 5 plan/usage alert events.

on_user_create intentionally does not pass groups (user-scope event, no org context at signup time).

Out of scope (separate PR)

  • App groups (groups: { app: appId }) — straightforward follow-up once this lands.
  • Fixing the broader "distinct_id should be the user UUID, not the org/app UUID" identity issue — that's the next PR on the list.

Test plan

  • bun lint:backend — clean
  • bun typecheck — clean
  • bunx vitest run tests/tracking.unit.test.ts tests/plans-onboarding-reminder.unit.test.ts — 3 tests pass
  • After merge, verify in PostHog that new events show $groups.organization on the event properties (filter by $groups.organization is set).
  • Verify Groups → Organizations tab in PostHog starts populating with properties from groupIdentifyPosthog calls.
  • Rebuild one insight on the Capgo KPIs dashboard with breakdown by organization to confirm org-level aggregation works.

Summary by CodeRabbit

  • Chores
    • Tracking events now include organization-level grouping and group identification across backend functions, triggers, and utilities.
    • Analytics payloads consistently carry organization context to improve organization-level metrics, alerting, and subscription/event tracking.
    • Added support for sending group-identify calls to the analytics provider for richer org-level insights.

sendEventToTracking now carries an optional `groups` field and forwards it
as `$groups` to PostHog so every server-side event is attributed to an
organization (and future group types). Previously all org-scoped events
landed on an anonymous person record keyed by the org UUID with no way to
aggregate by org in PostHog dashboards.

- utils/posthog.ts: add `$groups` to capture body; add `groupIdentifyPosthog`
  helper for `$groupidentify` events.
- utils/tracking.ts: add `groups?: PostHogGroups` to the payload and
  forward it to `trackPosthogEvent`. LogSnag ignores unknown fields.
- on_organization_create: `$groupidentify` the new org with name,
  management_email, customer_id, created_by, created_at, website.
- stripe_event: `$groupidentify` with plan_name / plan_status / plan_type
  on subscription created/updated, and plan_status=canceled on cancel.
- Pass `groups: { organization: orgId }` on every existing tracking call
  across triggers and private endpoints (on_app_create,
  on_version_create, on_deploy_history_create, credit_usage_alerts,
  upload_link, delete_failed_version, events, plans.ts, stripe_event).

This unblocks org/plan breakdowns in PostHog dashboards (e.g. LogSnag KPI
port) without needing a backend data-warehouse join.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 131824ab-7505-46f1-bde0-3948031c198a

📥 Commits

Reviewing files that changed from the base of the PR and between 2b318a6 and af77bb1.

📒 Files selected for processing (1)
  • supabase/functions/_backend/private/events.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • supabase/functions/_backend/private/events.ts

📝 Walkthrough

Walkthrough

This PR adds PostHog group support: a new group-identify helper, extends tracking payloads with an optional groups field, and invokes group identification in organization and subscription-related flows across backend functions.

Changes

Cohort / File(s) Summary
PostHog utils & tracking core
supabase/functions/_backend/utils/posthog.ts, supabase/functions/_backend/utils/tracking.ts
Add PostHogGroups type, extend capture payloads with optional groups, implement groupIdentifyPosthog, and forward groups from executeTracking into PostHog captures.
Stripe & subscription triggers
supabase/functions/_backend/triggers/stripe_event.ts
Include groups: { organization: org.id } in tracking payloads and add async groupIdentifyPosthog calls to surface subscription/plan properties and cancellations.
Organization creation trigger
supabase/functions/_backend/triggers/on_organization_create.ts
Call groupIdentifyPosthog for new organizations (groupType organization, groupKey = org id) and include groups: { organization: record.id } in tracking events.
General event & webhook handlers
supabase/functions/_backend/private/events.ts, supabase/functions/_backend/private/delete_failed_version.ts, supabase/functions/_backend/private/upload_link.ts
Propagate groups: { organization: <orgId> } into tracking payloads where user/org context exists; adapt resolveTrackingUserId return shape to include verified org id.
App/version/deploy/alerts triggers
supabase/functions/_backend/triggers/on_app_create.ts, supabase/functions/_backend/triggers/on_deploy_history_create.ts, supabase/functions/_backend/triggers/on_version_create.ts, supabase/functions/_backend/triggers/credit_usage_alerts.ts
Add groups.organization to emitted tracking events for app/version/deploy and credit alerts (no other control-flow changes).
Plan/usage notification utilities
supabase/functions/_backend/utils/plans.ts
Attach groups: { organization: orgId } to upgrade, usage-threshold, and onboarding tracking events.

Sequence Diagram(s)

sequenceDiagram
  participant Trigger as Trigger (DB/Event)
  participant Backend as Backend Function
  participant BG as BackgroundTask runner
  participant PostHog as PostHog API

  Trigger->>Backend: event (e.g., org create / stripe / deploy)
  Backend->>Backend: build tracking payload (includes user_id, tags, groups?)
  alt groupIdentify needed
    Backend->>BG: enqueue groupIdentifyPosthog(payload)
    BG->>PostHog: POST $groupidentify (distinct_id: "$organization_<id>", $group_set)
  end
  Backend->>PostHog: POST capture (include properties and $groups when present)
  PostHog-->>Backend: 200/ack
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • Dalanir

Poem

🐰 I twitch my nose at events,
Groups snug in every send,
Orgs and plans
Join my small clan,
Hop—analytics bloom, my friend!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(backend): PostHog organization groups + $groupidentify' clearly and specifically summarizes the main changes: adding PostHog organization groups support and the groupidentify feature to the backend.
Description check ✅ Passed The PR description comprehensively covers summary, context (why), detailed changes, and test plan. All required template sections are present and substantively filled with relevant technical details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/posthog-groups

Comment @coderabbitai help to get the list of available commands and usage tips.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented Apr 21, 2026

Merging this PR will not alter performance

✅ 28 untouched benchmarks


Comparing feat/posthog-groups (af77bb1) with main (d52aca1)

Open in CodSpeed

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b318a6657

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

...trackedBody,
bento: onboardingBentoEvent,
sentToBento: Boolean(onboardingBentoEvent),
groups: trackingUserId ? { organization: trackingUserId } : undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use org ID before attaching PostHog organization groups

trackingUserId is not guaranteed to be an organization ID here: when user_id is omitted (common API-key flow) or is a user UUID (e.g. login tracking), resolveTrackingUserId() returns an authenticated user id, but this line still sends groups: { organization: trackingUserId }. That mislabels person-scoped events as organization-scoped in PostHog and pollutes org group analytics/cohorts with user UUIDs. Only set the organization group when you have a verified org id (requested org or app owner org).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/functions/_backend/private/events.ts`:
- Line 149: groups currently uses trackingUserId which may be a user id; replace
it with an explicit organization id variable (e.g., trackingOrgId) and set
trackingOrgId from the request/context payload field that actually holds the org
id (for example payload.organizationId, tenantId, or the owner org field where
trackingUserId is derived). Then change the groups assignment to use
trackingOrgId: groups: trackingOrgId ? { organization: trackingOrgId } :
undefined, and ensure trackingOrgId is computed alongside where trackingUserId
is set so org grouping only receives valid org ids.

In `@supabase/functions/_backend/triggers/on_organization_create.ts`:
- Around line 32-38: The properties object currently includes raw identifiers
(management_email, customer_id, created_by); remove these sensitive fields from
the org group profile and only include non-identifying segmentation data (e.g.,
name, created_at, website, plan/tier). If you must emit an identifier for
deduping or linking, replace each raw value with a pseudonymized/hash (use a
deterministic HMAC or a helper like hashIdentifier/pseudonymizeIdentifier) so
the payload contains no direct or linkable IDs; update the code that builds the
properties object (properties) to use the sanitized fields instead.

In `@supabase/functions/_backend/triggers/stripe_event.ts`:
- Around line 483-489: The groupIdentifyPosthog call is setting canceled_at to
new Date().toISOString() (worker processing time); replace that with the Stripe
event timestamp so cohorts reflect when Stripe emitted the cancellation. Use the
Stripe event's created field (e.g., event.created) or the object's canceled_at
if available, convert it to ISO (new Date(event.created * 1000).toISOString())
and pass that value into the properties.canceled_at in the
backgroundTask(groupIdentifyPosthog(...)) call, keeping the rest of the call (c,
org, etc.) unchanged.

In `@supabase/functions/_backend/utils/posthog.ts`:
- Around line 251-273: The URL construction and outbound fetch in the posthog
group-identify flow are unsafe: move the new URL(...) call inside the existing
try block (or its own try) so URL parsing errors are caught, and make the fetch
call cancellable with an AbortController + timeout (matching the `$exception`
path pattern) to avoid hanging on slow hosts; update the code around posthogUrl,
getEnv/POSTHOG_CAPTURE_URL, and the fetch invocation to use the abortable
request and ensure all errors from URL creation or fetch are routed into the
same structured failure handling used elsewhere.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 316e4b6b-1abd-4526-8d5f-ab57e8b0a5f2

📥 Commits

Reviewing files that changed from the base of the PR and between d52aca1 and 2b318a6.

📒 Files selected for processing (12)
  • supabase/functions/_backend/private/delete_failed_version.ts
  • supabase/functions/_backend/private/events.ts
  • supabase/functions/_backend/private/upload_link.ts
  • supabase/functions/_backend/triggers/credit_usage_alerts.ts
  • supabase/functions/_backend/triggers/on_app_create.ts
  • supabase/functions/_backend/triggers/on_deploy_history_create.ts
  • supabase/functions/_backend/triggers/on_organization_create.ts
  • supabase/functions/_backend/triggers/on_version_create.ts
  • supabase/functions/_backend/triggers/stripe_event.ts
  • supabase/functions/_backend/utils/plans.ts
  • supabase/functions/_backend/utils/posthog.ts
  • supabase/functions/_backend/utils/tracking.ts

Comment thread supabase/functions/_backend/private/events.ts Outdated
Comment thread supabase/functions/_backend/triggers/on_organization_create.ts
Comment thread supabase/functions/_backend/triggers/stripe_event.ts
Comment thread supabase/functions/_backend/utils/posthog.ts
…ied org id

resolveTrackingUserId can return the authenticated user id (common API-key
flow and the /events login tracking path) when the caller omits user_id
or passes their own user UUID. The previous patch passed that id through
as `groups: { organization: trackingUserId }`, mislabeling person-scoped
events as organization-scoped and polluting the PostHog organization
group with user UUIDs.

resolveTrackingUserId now returns { trackingUserId, orgId? } — orgId is
only populated in the two branches that actually verify an organization
(app owner_org match or org.read permission). The caller forwards orgId
to PostHog groups and falls back to undefined otherwise.

Also switches the notifyConsole broadcast to use the already-verified
`requestedOrgId` for `org_id` instead of trackingUserId, since that path
is guarded by an explicit org id precondition.
@riderx riderx merged commit 31d3533 into main Apr 21, 2026
13 checks passed
@riderx riderx deleted the feat/posthog-groups branch April 21, 2026 14:31
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants